feat: x402BatchSettlement contract#1950
feat: x402BatchSettlement contract#1950CarsonRoscoe wants to merge 22 commits intox402-foundation:mainfrom
Conversation
ilikesymmetry
left a comment
There was a problem hiding this comment.
First pass gut says lots of code and would like to trim down and simplify where possible. Going to take more passes.
|
Directionally, I think we should move towards more statelessness like this: https://gist.github.com/ilikesymmetry/b351bd6ca47a9419c91b195bce332952 |
|
@CarsonRoscoe is attempting to deploy a commit to the Coinbase Team on Vercel. A member of the Team first needs to authorize it. |
ilikesymmetry
left a comment
There was a problem hiding this comment.
Multiple security issues needing resolution before proceeding to audit!
| struct VoucherClaim { | ||
| Voucher voucher; | ||
| bytes signature; | ||
| uint128 claimAmount; |
There was a problem hiding this comment.
now that we support authorizers signing, this probably needs to be totalClaimed to protect against replays
| bytes32 public constant REFUND_TYPEHASH = | ||
| keccak256("Refund(bytes32 channelId)"); |
There was a problem hiding this comment.
needs replay protection in case channel redeposited
| bytes32 public constant CLAIM_BATCH_TYPEHASH = | ||
| keccak256("ClaimBatch(bytes32 claimsHash)"); |
There was a problem hiding this comment.
would prefer to have full types here as this feels like a hack
| event ChannelCreated(bytes32 indexed channelId, ChannelConfig config); | ||
| event Deposited( | ||
| bytes32 indexed channelId, | ||
| uint128 amount, | ||
| uint128 newBalance | ||
| ); | ||
| event Claimed( | ||
| bytes32 indexed channelId, | ||
| uint128 claimAmount, | ||
| uint128 newTotalClaimed | ||
| ); | ||
| event Settled( | ||
| address indexed receiver, | ||
| address indexed token, | ||
| uint128 amount | ||
| ); |
| public receivers; | ||
|
|
||
| // ========================================================================= | ||
| // Events |
| (address recovered, ECDSA.RecoverError err, ) = ECDSA | ||
| .tryRecoverCalldata(digest, vc.signature); | ||
| if (err != ECDSA.RecoverError.NoError || recovered != payerAuth) { | ||
| revert InvalidSignature(); | ||
| } |
There was a problem hiding this comment.
feel like we should just use recover instead of try given we just revert anyways?
| function _verifyReceiverAuthorizer( | ||
| bytes32 typehash, | ||
| bytes32 data, |
There was a problem hiding this comment.
don't feel like we need this function at all. Just compute digest on outer layer and do isValidSignatureNow check where we need it
| using SafeERC20 for IERC20; | ||
|
|
||
| /// @inheritdoc IDepositCollector | ||
| function collect( |
There was a problem hiding this comment.
CRITICAL: This function is permissionless and lets anyone choose the recipient, letting anyone frontrun transactions and steal funds. This needs to be bound to the BatchSettlement singleton and only forward funds there and also lock down the caller to the singleton too. Same for all other collectors and would recommend making an abstract DepositCollector for this purpose.
| /// @dev Enables a single-tx deposit for tokens that implement EIP-2612, without requiring the payer | ||
| /// to have previously approved Permit2. The EIP-2612 permit call is soft-fail (try/catch) so that | ||
| /// pre-existing approvals or replayed permits don't revert the entire deposit. | ||
| contract Permit2WithERC2612DepositCollector is Permit2DepositCollectorBase { |
There was a problem hiding this comment.
feel like we should just merge this with other Permit2 collector and do a pattern like this for handling the optional 2612 approval. Would let us go down from 3->1 contracts for the use case and only add a single conditional block to the implementation. Seems net simpler.
| struct Voucher { | ||
| ChannelConfig channel; | ||
| uint128 maxClaimableAmount; | ||
| } |
There was a problem hiding this comment.
I don't love how much calldata there is here. I think there's an optimization we could make but will save it until we fix the security issues.
Description
Implements
x402BatchSettlement, the onchain escrow contract powering thebatch-settlementx402 payment scheme. This scheme is designed for high-frequency API access, clients pre-fund a subchannel, sign off-chain cumulative vouchers per request, and the server batch-claims them onchain at its discretion. No per-request gas cost.Deployed to Base Sepolia (
0x40200e6f073aCB938e0Cf766B83f4E5286E60003) for parallel SDK development.Contract Overview
The contract manages two layers: a service registry (one record per server) and subchannels (one per
(serviceId, payer, token)triple).Service lifecycle
Servers register a
serviceIdfirst-come-first-serve, specifying an initialpayToaddress, an initial authorizer, and awithdrawWindow(bounded 15 min – 30 days). Registration can be done directly or gaslessly via an EIP-712 signedregisterFor. Admin operations (add/remove authorizer, updatepayTo, update withdraw window) are all gaslessly submittable — signed by an existing authorizer and consumed with anadminNonceto prevent replay. At least one authorizer must always remain.Subchannel lifecycle
A subchannel is identified by
(serviceId, payer, token), making services token-agnostic — any ERC-20 can be deposited. Three gasless deposit methods are supported:receiveWithAuthorization) — ideal for USDC, fully off-chainpermitWitnessTransferFrom) — works for any ERC-20 with Permit2 approval, with aDepositWitnessbinding the deposit to a specific servicePayment flow
Clients sign EIP-712
Vouchermessages per request. Each voucher carries a monotonically increasingnonceand a cumulativecumulativeAmount. The server accumulates these off-chain and batches them into a singleclaim(serviceId, token, VoucherClaim[])call. Claimed amounts accumulate inunsettled[serviceId][token]and are transferred topayTovia a separatesettle(serviceId, token). The split lets servers amortize gas across arbitrarily many payers.Voucher signature verification
Signatures are verified by pure ECDSA recovery — no EIP-1271. Smart contract wallets are supported via client signer delegation: a payer authorizes an EOA hot wallet to sign on their behalf (
authorizeClientSigner/authorizeClientSignerFor). Delegation is per-service and uses aclientNoncefor gasless replay protection.Withdrawals
Three exit paths:
CooperativeWithdrawas authorizer; unclaimed deposit is refunded immediately, can batch multiple payers.RequestWithdrawal(includeswithdrawNonceto prevent replay after cooperative withdraw); facilitator submitsrequestWithdrawalFor. After the window, anyone callswithdraw.requestWithdrawaldirectly.Both reset paths preserve the voucher
nonceso old vouchers cannot be replayed after re-deposit. ThewithdrawNonceincrements on each cooperative withdraw to prevent authorizer signature replay.Tests
Test Results — 174/174 passed, 0 failed
X402BatchSettlementTestCoverage — Production Contracts
x402BatchSettlement.solx402BatchSettlementis the primary new contract and hits 100% on all four coverage axesChecklist